Skip to content

fix(mcp,runtime): describe_object serves readonly: true where a write never sets the value - #33150

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-6xoa43
Nov 13, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-6xoa43

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #32530

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:MCP 的 describe_object(HTTP 与 stdio 两种传输)和 objectstack://objects/{name} 资源,现在对「写了也不会生效」的字段回答 readonly: true:自动编号(包括手写、没带 readonly 标记的字段)、公式字段、声明了 expression 的存储计算字段。HTTP 传输按字段的原始声明判断(调用者看不到表达式引用的字段时,表达式会被遮蔽,但仍回答 readonly: true,表达式本身不外露);stdio 与资源只拿得到遮蔽后的对象,这一情形的缺口已另立 mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156。工具说明同时写明:readonly 是 schema 的判断,不是调用者的字段编辑权限;defaultValue 可能是服务端解析的令牌(NOW()、current_user)或表达式对象,代理应省略该字段而不是照抄。
  • 为什么改:之前只看字段上声明的 readonly 标记,代理会以为这些字段可写,写进去被引擎丢弃或覆盖,再猜再试。
  • 风险与回滚:只放宽回答(多说了 readonly: true),不拒绝任何新输入;普通字段仍是 false、声明了只读的仍是 true(有对照用例)。回滚 = revert 本 PR,不涉及数据。
  • 先看:packages/runtime/src/domains/mcp.ts 与 packages/mcp/src/mcp-server-runtime.ts 新增的 writeNeverSetsField(两处孪生实现);packages/mcp/src/mcp-http-tools.ts 的 describe_object 说明文字。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/mcp-object-schema-mask.test.ts -t 32530,预期 4/4 通过(真实引擎 + 安全插件)。
  • 需要你做的:无,席位落地。

What changes

  • Item 1, both twins. describeServedObject in packages/runtime/src/domains/mcp.ts (HTTP bridge) and in packages/mcp/src/mcp-server-runtime.ts (stdio bridge and the objectstack://objects/{name} resource) now serve readonly from writeNeverSetsField(f), one small predicate per twin: the declared flag, OR a runtime-owned type (RUNTIME_OWNED_FIELD_TYPES from @objectstack/spec/data, today autonumber), OR a formula (isVirtualSearchField, the spec's one statement of the virtual set; the engine's write strip keys on the same type), OR a stored field declaring an expression (the spec's expression contract: "a client-sent value never stands"; truthy, the engine's own test in planStoredExpressions). No local type list.
  • Round 2 (contract review S1): the HTTP twin judges on the declaration. The caller's field mask deletes a readable field's expression when it names a field the caller cannot read, while the engine still computes the column. describeObject in packages/runtime/src/domains/mcp.ts now runs readServedObject and servedToCaller as two steps and calls describeServedObject(def, declared): readonly comes from writeNeverSetsField(declared.fields[k]), and everything served is still built from the masked def, so the expression stays hidden. No new read: declared is the object the bridge already masks. The stdio twin and the objects resource hold only the masked object (callerObjectView), so there the expression case answers true only where the caller is shown the expression; the mcp twin's JSDoc and the changeset say so.
  • The stored-expression arm is a bounded same-class fix. The card names autonumber and formula; a non-formula field with an expression is the same defect (served readonly: false, the engine overwrites the sent value, measured below), on the same line, with the same gate family, and no other claim holds the function. Leaving it would keep the card's complaint for that field kind.
  • Item 2: option (b). The served view cannot answer the caller's edit verdict without a new read. Measured: the caller's object is servedToCaller(def) over resolveObjectMasker → resolveObjectSchemaMaskPosture, which asks the security service getMetadataReadableFields / getReadableFields and canReadObject only; the posture carries a readable set and nothing about edit. The edit verdict lives in ISecurityService.getWritableFields(object, context) (optional member, the write gate's complement). So the description now says readonly is the schema's, not the caller's edit permission, and that a write naming a read-only-for-you field is refused. What an edit verdict would need is in the acceptance notes.
  • Item 3. The optionFieldsOf JSDoc no longer says describe_object "serves what it served before"; the describe_object description gains the defaultValue sentence (token or expression object: omit the field, never copy the value) and the readonly wording above.
  • Skills-docs: not owed. packages/mcp/src/skill-md.ts names describe_object (fields: name, type, required) but restates neither readonly nor defaultValue, so it and its console mirror are untouched.

Tests

  • packages/runtime/src/domains/mcp-object-schema-mask.test.ts, new top-level describe [#32530] on the file's existing real-layer harness (real ObjectQL over SqlDriver, real SecurityPlugin, the real /mcp entry):
    • precondition: the hand-written autonumber literal reaches the served object with no readonly flag;
    • ticket_no (autonumber), total (formula), bare_formula (formula, no expression), doubled (number + expression) are served readonly: true; CONTROL title and qty stay false, frozen_note (declared readonly) stays true;
    • through the same bridge's create, the engine stores none of the sent values: the autonumber is issued (T-0001 shape), the formula reads 6, the stored expression is 6, the CONTROL field is stored;
    • the item-2 arm: locked_note (member may read, not edit) is served readonly: false, and create naming it is refused PERMISSION_DENIED / 403;
    • round 2: marked_up is a stored expression over hidden_cost, which the member may not read. It is served readonly: true, and the served answer contains neither hidden_cost nor any record. text. Preconditions: the member's /metadata/object read serves marked_up with no expression, while engine.getSchema still carries it.
  • packages/mcp/src/stdio-data-bridge.object-schema-mask.test.ts: new top-level describe [#32530] pinning the same fields over stdio describe_object and over buildObjectSchemaResource; the existing DECLARED_ANSWER pin had bonus_formula at readonly: false, which encoded the defect, and now reads true.

Acceptance notes

  • What an edit verdict (item 2a) would need, measured. HTTP bridge: one security.getWritableFields(name, ec) call per describe_object, feature-detected, beside the masker (the service is already resolved there). stdio bridge: StdioDataBridgeDeps.security is typed ObjectSchemaMaskSecuritySurface, which has no getWritableFields, so the dependency type and callerObjectView widen (stdio-data-bridge.ts, outside this card's surface). The objects resource: buildObjectSchemaResource reads an McpObjectView (listObjects / getObject) with no principal, so it cannot ask at all without a new seam. And the contract (getWritableFields JSDoc) requires a consumer without that member to state in its response that it narrowed by the read projection, so the answer would also gain a coverage key. Not filed: it is the card's own option (a), deliberately not taken.
  • Out of scope, filed as mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156: over stdio and on the objectstack://objects/{name} resource, a caller who cannot read a field that a stored expression names is served readonly: false for that field, while the HTTP door answers true. Those two readers hold only the masked object; the declaration is not reachable there without a second metadata read, and stdio-data-bridge.ts is outside this card's surface.
  • summary is left readonly: false. COMPUTED_VALUE_TYPES calls it server-computed, but the engine persists a caller-sent summary value (stripComputedWriteFields JSDoc: "a roll-up HAS a column, and a caller-supplied value is persisted"), so readonly: true would claim a drop that does not happen. Noted, not filed: the engine records the question as its own.
  • The formula predicate is search-named. isVirtualSearchField is the spec's only exported statement of "computed on read, no column"; the write strip in objectql keys on type === 'formula' literally and isRuntimeOwnedField lives in objectql, which @objectstack/mcp does not depend on. Both read the same set today. A write-named spec predicate would be its own spec change.
  • Serial. Edits stay on describeServedObject / writeNeverSetsField, the optionFieldsOf JSDoc and the describe_object description; no hunk touches servedObjectDefinitionOf, the validate_expression handler (fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134) or the connector narrowing (objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699).
  • Ruling grep (ruling|Ruling-ref|maintainer|⛔) on the touched surface: 69 hits across the three source files. The ones inside the edited regions are the describeServedObject JSDoc's "⛔ never its condition, pattern or transition table" (both twins, unchanged, honoured: no rule expression is served) and the new JSDoc's own "⛔ Not the caller's field edit permission". No ruling record governs readonly. The mcp-http-tools.ts hits sit outside the edited JSDoc and description.

Verification

Round 1 readings were taken at 819ea267 (base 932eab54; origin/main merged once there, bringing only packages/lint changes). Round 2 readings were taken at head 92fba691: the S1 fix d05eae15, the changeset narrowing, then origin/main merged a second time because #33144 landed on packages/runtime/src/domains/mcp.ts (no conflict).

Round 2 (S1) at 92fba69
  • mcp-object-schema-mask.test.ts (runtime) — Tests 72 passed (72); src/domains/mcp* (runtime) — Test Files 5 passed (5), Tests 247 passed (247)
  • pnpm --filter @objectstack/mcp test — Test Files 63 passed (63), Tests 1106 passed (1106)
  • pnpm --filter @objectstack/mcp --filter @objectstack/runtime typecheck — VERDICT command-exit 0
  • Ablations, both red and both restored to the HEAD blob: readonly: writeNeverSetsField(f) back in place gives 1 failed (the new marked_up case); serving declared.fields[k].expression gives 1 failed (the same case, its no-expression assertion).
  • Derived gates re-derived at 92fba691: the same 66 families, each run with stdin closed and its exit code recorded; --ran reconciles 66 accounted, 66 run, 0 NOT MEASURED, every one exit 0 (check:dual-build-cjs-loads included this time, after the post-merge closure build). pnpm lint exit 0.
Measured at origin/main first (the premise holds)

The new runtime pins over the unfixed twin (4fee53be, tests only on 932eab54): 1 failed | 3 passed. The failing case: doubled, ticket_no and total were served readonly: false (the frozen_note and plain-field controls matched). The three passing cases are the measurement of the engine and the gate: the autonumber literal carries no flag, the engine dropped or replaced every sent computed value, and the member's write naming locked_note was refused PERMISSION_DENIED / 403 while describe_object served it readonly: false. On the stdio side, DECLARED_ANSWER pinned bonus_formula at readonly: false on main.

Package tests and typecheck
  • pnpm --filter @objectstack/mcp test — Test Files 63 passed (63), Tests 1106 passed (1106)
  • pnpm --filter @objectstack/runtime test — Test Files 596 passed (596), Tests 10581 passed | 19 skipped (10600), VERDICT command-exit 0
  • mcp-object-schema-mask.test.ts (runtime) — Tests 71 passed (71)
  • pnpm --filter @objectstack/mcp --filter @objectstack/runtime typecheck — VERDICT command-exit 0; both run tsc --noEmit && pnpm check:test-typecheck, the second over tsconfig.test.json, which carries the test files
Ablations (12, all red, all restored)

Every leg through scripts/ablation-replace.mjs (anchor x1 → x0, blob changed, restored blob == HEAD and git diff HEAD empty). The suites import the source, so no dist/ leg.

  • Runtime twin, -t 32530: whole verdict back to f?.readonly === true → 1 failed; runtime-owned arm removed → 1 failed; formula arm removed → 1 failed; expression arm → false → 1 failed; declared arm removed → 1 failed; CONTROL (last arm → true) → 2 failed (the plain-field control and the item-2 locked_note case).
  • mcp twin, stdio-data-bridge.object-schema-mask.test.ts: whole verdict → 4 failed (the two new cases plus the two DECLARED_ANSWER cases); runtime-owned arm → 2 failed; formula arm → 2 failed; expression arm → 2 failed; declared arm → 3 failed; CONTROL → 5 failed.
Derived gates and lint

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 819ea267: 66 families, each run with stdin closed and its exit code recorded before any pipe. --ran reconciles 66 accounted, 65 run, 1 NOT MEASURED:

  • pnpm check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: the scan needs dist/ of packages outside this diff's closure. NOT MEASURED; CI runs it.

All other 65 exit 0, check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:doc-authoring and check:adr-0087-registration among them. node scripts/check-plugin-teardown-shape.mjs --self-test first exited 3 (its pinned positive control was not in the shallow clone); after fetching 621a4876, exit 0, 48 cases. pnpm lint — exit 0. No export was renamed or removed, so check-platform-checklist.mjs is not owed.


Generated by Claude Code

… sets the value

A runtime-owned type, a formula and a stored field declaring an
expression are served readonly: true, beside the declared flag. The
describe_object description says readonly is the schema's, not the
caller's edit permission, and that a defaultValue may be a token or an
expression the server resolves.

Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 13, 2026
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/mcp, @objectstack/runtime, touching 5 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/authorization.mdx (via buildMcpBridge (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 128b6571f476d4fae347d8f860b04b95927d2cce → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c272bbaa8ae3e62e1df8bf4ee630c9972c6d97f2 — the merge of head 92fba691f927d91ef8ac9103042b8ef7236de7b0 into base 128b6571f476d4fae347d8f860b04b95927d2cce, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c272bbaa8ae3e62e1df8bf4ee630c9972c6d97f2 && git checkout c272bbaa8ae3e62e1df8bf4ee630c9972c6d97f2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 128b6571f476d4fae347d8f860b04b95927d2cce 92fba691f927d91ef8ac9103042b8ef7236de7b0 && git checkout -B drift-repro 128b6571f476d4fae347d8f860b04b95927d2cce && git merge --no-ff 92fba691f927d91ef8ac9103042b8ef7236de7b0

node scripts/docs-audit/affected-docs.mjs --json 128b6571f476d4fae347d8f860b04b95927d2cce

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 128b6571f476d4fae347d8f860b04b95927d2cce → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 819ea267b98ed14fd84c2055446301bd082ddf61
Local-runs: none

① Derived judgments

  • Runtime-owned arm: right. RUNTIME_OWNED_FIELD_TYPES (spec/data/field.zod.ts, {autonumber}) is the set objectql strips on both verbs: insert stripRuntimeOwnedFields, update stripReadonlyFields through isRuntimeOwnedField (objectql/validation/rule-validator.ts). The only exemptions are isSystem and create-side preserveAudit, and MCP passes neither (no preserveAudit in packages/mcp/src or runtime/src/domains/mcp.ts). So create versus update agree, and a flagless literal is now true.
  • Formula arm: right. isVirtualSearchField is SEARCH_VIRTUAL_TYPES = {formula} (spec/data/search-fields.ts). That is the same key stripComputedWriteFields (objectql/engine-top-level.ts) strips on, in every context, on insert, update and validate.
  • Stored-expression arm: right. Boolean(f.expression) equals planStoredExpressions's def.type !== 'formula' && def.expression, and formula is already true by then. The engine overwrites the value in every case: insert computeStoredExpressions then Object.assign; by-id update applyStoredExpressionsToPatch overwrites a key the caller sent; predicate update settleBulkStoredExpressions deletes the caller's key. Nothing only defaults it.
  • No false true found. summary stays false (the engine persists a value the caller sends). readonlyWhen is untouched. System columns already carried the declared flag. A plain field stays false. An FLS edit-deny stays false by design (item 2b).
  • Twins: agree by copy, not by construction. Each twin has its own writeNeverSetsField over the same two spec imports, plus a local expression test that mirrors objectql. No local type list. They differ only in the guard (typeof f !== 'object' in runtime), which is harmless.
  • S1 (should-fix): the stored-expression arm misses for a field-projected caller. mcp-server-runtime.ts:writeNeverSetsField / runtime/domains/mcp.ts:writeNeverSetsField read the masked definition (callerObjectView → applyObjectSchemaMask; maskObjectSchema). metadata-core/object-schema-fls-references.ts FIELD_REFERENCE_POSITIONS.expression deletes a readable field's expression when it mentions a field the caller is denied. That caller is served doubled as readonly: false while the engine still overwrites the value. Nothing serves a wrong true (no regression; the field shows the pre-PR answer), but the changeset's "for a stored field that declares an expression" is not true for that caller, and no pin covers it. Fix one of two ways: compute the verdict from the definition before the mask (it reveals only "computed", not the inputs), or narrow the changeset and JSDoc sentence and record the gap as a follow-up.
  • N1 (nit): the formula predicate is named for search. It is the right set today, and a write-named spec predicate would be a spec change of its own (the dev flags this).

② Semver level

  • patch for @objectstack/mcp and @objectstack/runtime is right. The diff publishes only these two. Metadata-protocol: none is right: no authorable key, value or default changes. Clause-②: yes (widening) is right: an existing answer reads true more often and no input is newly refused. Both lines open a line in the PR body and in the changeset body (AGENTS.md Post-Task Checklist 3). This is not BREAKING.
  • No Skills-docs: line is owed. packages/mcp/src/skill-md.ts, ui/apps/console/src/pages/developer/objectstack-skill.ts and skills/objectstack-ai/SKILL.md name describe_object but restate neither readonly nor defaultValue.
  • N2 (nit): the stale sentence item 3 removes ("describe_object serves what it served before") survives in packages/mcp/src/mcp-validate-expression.option-labels.test.ts (header JSDoc). It is test prose and outside the card's named site.

③ Boundary flags

  • Item 2 (b): true. resolveObjectSchemaMaskPosture (metadata-core/object-schema-fls.ts) asks only getMetadataReadableFields/getReadableFields and canReadObject, and the posture carries readable only. The edit verdict is ISecurityService.getWritableFields (spec/contracts/security-service.ts), a separate read. The description sentence ("shows readonly false, and a write naming it is refused") is true: plugin-security's FLS write step (security-plugin-authz-middleware.ts 2.5) answers 403 and does not strip. The runtime pin measures PERMISSION_DENIED/403.
  • Expression arm scope: within the card's class. It is the same defect (value served writable, engine replaces it) on the same function, and the spec's expression contract says "a client-sent value never stands". It needs no card of its own. S1 is its only gap.
  • defaultValue sentence: true. Both twins serve f.defaultValue raw, and FieldSchema.defaultValue admits a CEL envelope or the NOW()/current_user tokens, applied on INSERT when the field is omitted or null. The rewritten optionFieldsOf JSDoc (mcp-http-tools.ts) is true: describe_object serves options under the same declaresOptions.
  • Pins and CONTROL: real. Runtime [#32530]: title/qty false and frozen_note true, beside the four computed fields true, over the real ObjectQL, SqlDriver and SecurityPlugin. The engine leg measures each sent value dropped or replaced. Stdio [#32530] covers the tool and buildObjectSchemaResource, and DECLARED_ANSWER's bonus_formula flips. The dev reports red without the change (1 runtime, 4 stdio) and red on each arm's ablation, CONTROL included; I did not re-run them. N3 (nit): the engine leg is create-only. Update rests on the engine code read above.
  • Gates (check-runs at head, 48): 14 success, 16 skipped, 18 in_progress (Build Core, Test Core 1–8/8, Type Check workspace/debt ledger/consumer gates, five Lint jobs), 0 failure. Merge waits on those.

Implemented-by: claude/project-thread-6xoa43
Reviewed-by: session_01L9qibzuZJBQY4wUQFJbkfx

VERDICT: PASS

…ot the masked one

The caller's field mask deletes a readable field's expression when it
names a field the caller cannot read; the engine still computes that
field. The HTTP bridge now reads only the boolean from the declaration
it masks and keeps serving the masked definition.

Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF
Co-Authored-By: Claude <noreply@anthropic.com>
…n each transport

Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 92fba691f927d91ef8ac9103042b8ef7236de7b0
Local-runs: none

① Derived judgments

The delta from 819ea267 is made of fix 56546bd5, changeset 48fff48a, and the merge 92fba691 (#33144, #33121, #33129). The net diff against main is still the PR's 7 files. d05eae15, named as the fix, is the blob of runtime/src/domains/mcp.ts at 48fff48a, not a commit.

  • S1 (R1 should-fix): fixed on HTTP. runtime/domains/mcp.ts buildMcpBridge.describeObject now inlines readObjectForCaller's two steps: readServedObject, then servedToCaller. It passes the declaration to describeServedObject(def, declared), which reads writeNeverSetsField(declared?.fields?.[k]) and nothing else from it. The keys still come from the masked def.fields, so a denied field gets no entry.
  • No new read. readServedObject is the same call readObjectForCaller made, and servedToCaller keeps the same per-request masker. The caller could already cause every read.
  • No other key leaks. name, label, type, required, system, options, defaultValue, validations and enableFeatures are all still read from def. The only fact the declaration adds is one boolean: "this readable field is computed". The expression and its inputs stay hidden. The mask (projectMetaObjectSchema → applyObjectSchemaMask, which is pure) never adds or deletes readonly (FIELD_REFERENCE_POSITIONS.readonly: keep), and it never changes type. Audit-field governance runs before the mask, in the protocol read (protocol-meta-type-schema.ts). So declared and def differ, for the predicate, only on expression. No other arm can move.
  • Twins: they now differ on purpose. The mcp twin's JSDoc (mcp-server-runtime.ts:writeNeverSetsField ⚠️) states the gap and points to the HTTP twin. The stdio bridge (stdio-data-bridge.ts → describeServedObject(def)) and the resource (buildObjectSchemaResource) are unchanged and read only the masked view (plugin.ts callerObjectView, or the bare view when there is no principal). The gap is filed as mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156 (open).

② Semver level

  • No change from round 1. patch for @objectstack/mcp and @objectstack/runtime. Clause-②: yes (widening) and Metadata-protocol: none still open lines in the changeset body. No Skills-docs: line is owed.
  • The stdio wording is exact. The changeset says "Over stdio and on the resource, which hold only the object as the caller is served it, the expression case answers true where the caller is shown the expression". With a principal that is the masked view; without one, the caller is served the bare view. Both are covered. The HTTP sentence ("judged on the field as declared … the expression itself stays hidden") matches the code.
  • N2 (R1 nit): fixed. mcp-validate-expression.option-labels.test.ts header.

③ Boundary flags

Implemented-by: claude/project-thread-6xoa43
Reviewed-by: session_01L9qibzuZJBQY4wUQFJbkfx

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

维护者速读 · PR #33150 @ 92fba691 · 2026-11-13T22:48Z

  • 改了什么:MCP describe_object(HTTP 与 stdio)和 objectstack://objects/{name} 资源,对「写了也不会生效」的字段回答 readonly: true:自动编号、公式字段、声明了 expression 的存储计算字段。工具说明写明 readonly 是 schema 的判断,不是调用者的编辑权限;defaultValue 可能是服务端令牌或表达式对象,代理应省略而不是照抄。
  • 为什么改:以前只看字段上声明的 readonly 标记,代理以为这些字段可写,写进去被引擎丢弃或覆盖,再猜再试(mcp: describe_object serves formula, autonumber and edit-denied fields as readonly: false #32530)。
  • 权限与边界:HTTP 按字段的原始声明判断 readonly,但回答的其余内容仍来自遮蔽后的对象。调用者看不到表达式引用的字段时,只多知道一个布尔值「此字段是计算的」,表达式和它的输入不外露。没有新增读取。stdio 与资源只拿得到遮蔽后的对象,这一缺口另立 mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156。
  • 风险与回滚:只放宽回答,不拒绝任何新输入;普通字段仍是 false,声明只读的仍是 true(有对照用例)。回滚就是 revert,不涉及数据。
  • 先看:packages/runtime/src/domains/mcp.ts 的 describeObject(声明与遮蔽分两步)和两处孪生 writeNeverSetsField。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。两轮契约复审(TIER_DEFAULT,widening 面)都 PASS;首轮的应修项 S1(字段遮蔽删掉表达式后误答 false)在 HTTP 上已修,stdio 侧缺口已立卡。CI 全绿,没有碰受管面,改动 372 行。

席位核对

  • 形态: draft → main,正文写 Fixes #32530,合并后应当关卡;正文其余地方没有挨着别的 open 卡号的闭合关键词。
    • Clause-②: yes (widening)、Metadata-protocol: none。
    • Skills-docs 不欠:skill-md.ts 提到 describe_object 但不复述 readonly / defaultValue,Console 镜像不变。
    • 文件面 = 认领 6862710400。
  • 前提实测: dev 在派发时的 origin/main 上实测了卡上的缺陷:手写的 autonumber 字面量、公式字段和存储表达式字段都被答成 readonly: false,而引擎会覆盖写入的值(报告 6863976414)。
  • 门禁 check-runs @ 92fba691: 35 success、18 skipped、0 失败。正文修订后重跑的 PR-body guards 和 Check Changeset 由自动合并等待。
    • 本地在 92fba691 上重新推导出 66 个门禁族,--ran 对账为 66 个已计、66 个已跑、0 个 NOT MEASURED,全部 exit 0。其中 check:dual-build-cjs-loads 是在合并后的闭包构建之后跑的。pnpm lint exit 0。记录在 server-seat-7/r2026-11-13/out/32530-gates-ran-92fba691.list。
  • 契约复审: 同一位复审者做了两轮。
    • 首轮 6864113097 在 819ea267 上 PASS,留一个应修项 S1:字段遮蔽会删掉可读字段上引用了不可读字段的 expression,于是该字段被答成 readonly: false,引擎却仍会计算这一列。另有 nit N1–N3。
    • 第二轮 6866437584 在 92fba691 上 PASS:
      • S1 在 HTTP 上已修,改为读声明、回答遮蔽视图,并加了 marked_up / hidden_cost 的 pin;
      • 没有新的读取,除这一个布尔值外也没有其他键外泄;
      • N2 已修;
      • 新增 nit N4(正文未更新),dev 已通过 relay issue_patch 修好,读回一致。
  • 接受的偏差:
    • N1:公式谓词沿用 isVirtualSearchField,名字说的是搜索;
    • N3:引擎一侧的 pin 只测 create;
    • 两处孪生实现有意不同:stdio 与资源只持有遮蔽视图,JSDoc 和 changeset 都写明了。
  • 范围外去向:
  • 落地条款: 维护者常设落地令 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 13, 2026 22:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 13, 2026
Merged via the queue into main with commit 28a2f0f Nov 13, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-6xoa43 branch November 13, 2026 23:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: describe_object serves formula, autonumber and edit-denied fields as readonly: false

2 participants